SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
10.7 KB · 191 lines tsx
Raw Blame History
1import { ExternalLink } from 'lucide-react';2import type { Metadata } from 'next';3import Link from 'next/link';4import { notFound } from 'next/navigation';5import { t, tOpt } from '@/i18n';6import { isNotBuilt, isNotFound } from '@/lib/api';7import { apiExplore } from '@/lib/api-explore';8import { cn } from '@/lib/cn';9import { compact, formatDate, grouped } from '@/lib/format';10import { routes } from '@/lib/site';11import type { ImportRun, SourceIndicatorRow, SourceResponse } from '@/lib/types-explore';12import { DataTable, type DataTableColumn } from '@/components/data/data-table';13import { NotBuiltState } from '@/components/data/empty-state';14import { FreshnessBadge } from '@/components/data/freshness-badge';15import { Section } from '@/components/data/section';16import { ACTION_CLS, PageHeader } from '@/components/explore/page-header';17import { FooterCredits } from '@/components/layout/site-footer';1819export const revalidate = 900;2021type Params = { id: string };2223async function load(id: string): Promise<SourceResponse | 'not-built' | null> {24  try {25    return await apiExplore.source(id, 60);26  } catch (e) {27    if (isNotFound(e)) return null;28    if (isNotBuilt(e)) return 'not-built';29    throw e;30  }31}3233export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {34  const { id } = await params;35  const data = await load(id);36  if (!data || data === 'not-built') return { title: t('source.notFound'), robots: { index: false } };37  const s = data.source;38  const title = t('source.title', { name: s.name ?? s.id });39  const description = t('source.description', { name: s.name ?? s.id, n: s.n_indicators ?? data.indicators.length, licence: s.licence ?? '' });40  return { title, description, alternates: { canonical: routes.source(s.id) }, openGraph: { title: `${title} — ${t('site.name')}`, description, url: routes.source(s.id), type: 'article' } };41}4243function RunStatus({ status }: { status: string | null }) {44  const s = (status ?? '').toLowerCase();45  const label = tOpt(`source.run.${s}`, status ?? t('common.na'));46  return <span className={cn('inline-flex items-center rounded-sm border px-1.5 py-0.5 text-2xs font-medium', s === 'ok' ? 'border-rule text-ink-2' : s === 'partial' ? 'border-warn/40 text-warn' : s === 'failed' || s === 'quarantined' ? 'border-down/40 text-down' : 'border-rule text-ink-3')}>{label}</span>;47}4849function duration(r: ImportRun): string {50  if (!r.started_at || !r.finished_at) return t('common.na');51  const s = (new Date(r.finished_at).getTime() - new Date(r.started_at).getTime()) / 1000;52  if (!Number.isFinite(s) || s < 0) return t('common.na');53  return s < 60 ? `${Math.round(s)} s` : `${Math.round(s / 60)} min`;54}5556export default async function SourcePage({ params }: { params: Promise<Params> }) {57  const { id } = await params;58  const data = await load(id);59  if (data === null) notFound();60  if (data === 'not-built') return <NotBuiltState />;61  const s = data.source;62  const name = s.name ?? s.id;6364  const indCols: DataTableColumn<SourceIndicatorRow>[] = [65    {66      key: 'indicator',67      header: t('source.series.indicator'),68      cell: (r) => (69        <Link href={routes.indicator(r.slug)} className="link-quiet inline-flex min-h-[32px] items-center text-ink">70          {r.name ?? r.slug}71        </Link>72      ),73    },74    { key: 'code', header: t('source.series.code'), cell: (r) => <code className="font-mono text-xs text-ink-2">{[r.dataset, r.series_code].filter(Boolean).join(' · ')}</code> },75    { key: 'priority', header: t('source.series.priority'), numeric: true, cell: (r) => (r.priority != null ? `#${r.priority}` : t('common.na')) },76    { key: 'coverage', header: t('source.series.coverage'), numeric: true, cell: (r) => (r.n_countries != null ? `${grouped(r.n_countries)} · ${r.n_observations != null ? compact(r.n_observations) : ''}` : t('common.na')) },77    { key: 'last', header: t('source.series.lastYear'), numeric: true, cell: (r) => r.last_year ?? t('common.na'), hideOnMobile: true },78    { key: 'status', header: t('source.series.status'), cell: (r) => <RunStatus status={r.last_status} /> },79  ];80  const runCols: DataTableColumn<ImportRun>[] = [81    { key: 'started', header: t('source.runs.started'), cell: (r) => <span className="tnum text-xs">{r.started_at ? `${formatDate(r.started_at)} ${r.started_at.slice(11, 16)}` : t('common.na')}</span> },82    { key: 'dataset', header: t('source.runs.dataset'), cell: (r) => <span className="break-all font-mono text-xs text-ink-2">{r.dataset}</span> },83    { key: 'status', header: t('source.runs.status'), cell: (r) => <RunStatus status={r.status} /> },84    { key: 'rows', header: t('source.runs.rows'), numeric: true, cell: (r) => (r.rows_valid != null ? grouped(r.rows_valid) : t('common.na')) },85    { key: 'warn', header: t('source.runs.warnings'), numeric: true, cell: (r) => (r.warnings != null ? grouped(r.warnings) : t('common.na')), hideOnMobile: true },86    { key: 'err', header: t('source.runs.errors'), numeric: true, cell: (r) => (r.errors ? <span className="text-down">{grouped(r.errors)}</span> : r.errors === 0 ? '0' : t('common.na')), hideOnMobile: true },87    { key: 'dur', header: t('source.runs.duration'), numeric: true, cell: (r) => duration(r), hideOnMobile: true },88  ];8990  return (91    <>92      <PageHeader93        crumbs={[{ href: routes.sources(), label: t('sources.title') }]}94        eyebrow={s.organization}95        title={name}96        lede={s.attribution}97        meta={98          <>99            <code className="font-mono">{s.id}</code>100            {s.licence ? ` · ${s.licence}` : ''}101            {s.n_indicators != null ? ` · ${t('sources.indicators', { n: grouped(s.n_indicators) })}` : ''}102            {s.n_observations != null ? ` · ${t('sources.observations', { n: compact(s.n_observations) })}` : ''}103          </>104        }105        actions={106          s.url ? (107            <a href={s.url} target="_blank" rel="noopener noreferrer" className={ACTION_CLS}>108              <ExternalLink size={14} aria-hidden /> {t('source.open', { name })}109            </a>110          ) : null111        }112      />113114      <section aria-label={t('source.freshness.title')} className="border-y border-rule">115        <dl className="grid grid-cols-1 gap-y-3 py-4 sm:grid-cols-3 sm:divide-x sm:divide-rule">116          <div className="sm:pr-4">117            <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('source.freshness.sourceUpdated')}</dt>118            <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.source_updated_at)}</dd>119          </div>120          <div className="sm:px-4">121            <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('source.freshness.retrieved')}</dt>122            <dd className="tnum mt-1 flex flex-wrap items-center gap-2 text-base font-semibold text-ink">123              {formatDate(s.last_success_at ?? data.freshness.retrieved_at)} <FreshnessBadge retrievedAt={s.last_success_at ?? data.freshness.retrieved_at} />124            </dd>125          </div>126          <div className="sm:pl-4">127            <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('source.freshness.built')}</dt>128            <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.built_at)}</dd>129          </div>130        </dl>131      </section>132133      <Section id="about" title={t('source.about')} className="border-t-0" tight>134        <dl className="grid gap-x-8 gap-y-2 text-sm sm:grid-cols-2">135          <div>136            <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('sources.organisation')}</dt>137            <dd className="text-ink">{s.organization ?? t('common.na')}</dd>138          </div>139          <div>140            <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('sources.licence')}</dt>141            <dd className="text-ink">{s.licence ?? t('common.na')}</dd>142          </div>143          <div>144            <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('sources.attribution')}</dt>145            <dd className="text-ink">{s.attribution ?? t('common.na')}</dd>146          </div>147          <div>148            <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('sources.apiBase')}</dt>149            <dd className="break-all font-mono text-xs text-ink">{s.api_base ?? t('common.na')}</dd>150          </div>151          {s.notes ? (152            <div className="sm:col-span-2">153              <dd className="text-ink-2">{s.notes}</dd>154            </div>155          ) : null}156        </dl>157      </Section>158159      {data.datasets.length ? (160        <Section id="datasets" title={t('source.datasets.title')} subtitle={t('source.datasets.sub')} tight>161          <DataTable162            rows={data.datasets}163            rowKey={(d) => d.dataset ?? 'null'}164            caption={t('source.datasets.title')}165            columns={[166              { key: 'dataset', header: t('source.datasets.dataset'), cell: (d) => <span className="font-medium text-ink">{d.dataset ?? t('common.na')}</span> },167              { key: 'obs', header: t('source.datasets.observations'), numeric: true, cell: (d) => (d.n_observations != null ? grouped(d.n_observations) : t('common.na')) },168              { key: 'ind', header: t('source.datasets.indicators'), numeric: true, cell: (d) => (d.n_indicators != null ? grouped(d.n_indicators) : t('common.na')) },169              { key: 'cty', header: t('source.datasets.countries'), numeric: true, cell: (d) => (d.n_countries != null ? grouped(d.n_countries) : t('common.na')) },170              { key: 'years', header: t('source.datasets.years'), numeric: true, cell: (d) => (d.first_year && d.last_year ? `${d.first_year}–${d.last_year}` : t('common.na')) },171            ]}172          />173        </Section>174      ) : null}175176      <Section id="series" title={t('source.series.title')} subtitle={t('source.series.sub', { n: grouped(data.indicators.length) })}>177        {data.indicators.length ? <DataTable rows={data.indicators} rowKey={(r) => `${r.slug}-${r.series_code}`} columns={indCols} caption={t('source.series.title')} dense /> : <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>}178      </Section>179180      <Section id="runs" title={t('source.runs.title')} subtitle={t('source.runs.sub', { n: grouped(data.import_runs.length) })}>181        {data.import_runs.length ? <DataTable rows={data.import_runs} rowKey={(r) => `${r.run_id}-${r.dataset}`} columns={runCols} caption={t('source.runs.title')} dense /> : <p className="text-sm text-ink-3">{t('source.runs.none')}</p>}182      </Section>183184      <div className="border-t border-rule pt-4 text-sm text-ink-2">185        <span className="mr-2 font-medium text-ink">{t('sources.contact')}</span>186        <FooterCredits className="mt-1 inline text-xs" />187      </div>188    </>189  );190}191